Skip to content

cl/forkchoice: harden Gloas envelope persistence - #23152

Open
domiwei wants to merge 8 commits into
mainfrom
kewei/gloas-envelope-storage-safety
Open

cl/forkchoice: harden Gloas envelope persistence#23152
domiwei wants to merge 8 commits into
mainfrom
kewei/gloas-envelope-storage-safety

Conversation

@domiwei

@domiwei domiwei commented Aug 10, 2026

Copy link
Copy Markdown
Member

Summary

  • replace Gloas envelope files through a temporary file and atomic rename so failed or concurrent replacements cannot expose partial data
  • record the envelope fork version in the file and decode with that version, including post-Gloas representations
  • reuse the fork graph SSZ buffer while detaching transaction bytes that alias decoder input
  • keep HasEnvelope on its cache-only hot path; only successful writes and validated reads promote the cache
  • serialize envelope reads, writes, and pruning so stale writers cannot restore pruned envelopes
  • retain structurally invalid files for diagnosis while marking them unusable in memory; transient I/O failures remain retryable
  • validate externally supplied envelopes before hashing, keep Gloas progressive resource guards distinct from protocol limits, and reject persisted representations that cannot round-trip through the configured decoder
  • enforce block-root identity, the 15 MiB transport bound, matching nested versions, collection bounds, and complete nested representations
  • bound prune lock hold time per root and remove envelope files after invalidating their in-memory lineage

Production scope

Caplin creates the fork-choice filesystem under dirs.Tmp/caplin-forkchoice and clears it before constructing the fork graph on every process start. This PR therefore provides intra-process atomicity, ownership safety, and prune ordering; it intentionally does not claim crash durability or startup recovery. Directory fsync, quarantine files, startup scavenging, and committed-with-durability-warning handling are outside the real lifecycle and are not included.

Beacon-state persistence remains unchanged. It has a different file format and lifecycle and should be hardened separately rather than expanding this envelope-focused change.

Root cause

Envelope writes truncated the final file in place, so a failed replacement could destroy the previously readable envelope and a concurrent reader could observe partial data. Reads decoded transaction slices from the fork graph's reusable SSZ buffer, allowing a later read or write to mutate an envelope already returned to a caller. Pruning also removed envelope files without coordinating with writers, allowing a concurrent rename to restore a pruned file.

Validation

  • go test ./cl/phase1/forkchoice/fork_graph ./cl/phase1/forkchoice ./cl/phase1/network/services ./cl/cltypes ./cl/cltypes/solid -count=1
  • focused go test -race for cache misses, read ownership, dump/read concurrency, prune/write ordering, write failures, and post-Gloas round trips
  • make lint twice
  • make erigon integration

The behavior changes were driven Red to Green through public network-service, forkchoice, and fork-graph methods. Regressions cover destructive replacement failures, shared-buffer mutation, prune/write resurrection, unrelated I/O during multi-root pruning, cache misses waiting on state I/O, transient open/read failures preserving trusted availability, structural corruption evicting warm cache, post-Gloas version loss, nested-version mismatch, null list members before hashing, protocol-versus-decoder bounds, alternate collection representations before persistence, double close, root/file identity mismatch, oversized allocation, malformed nested input, and failed-removal cache exposure.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Hardens Gloas execution payload envelope persistence in the fork-choice fork graph by making disk writes atomic/durable, reads ownership-safe (no shared buffer aliasing), and pruning/write/read operations serialized to avoid resurrecting or re-exposing pruned/corrupt envelopes.

Changes:

  • Switch envelope persistence to temp-file write + atomic rename + directory sync, plus startup cleanup of orphan temp/quarantine artifacts.
  • Make envelope reads decode from owned buffers, enforce root identity, and quarantine/remove structurally corrupt envelope files.
  • Add stronger envelope validation (Gloas-only, size bounds, complete representation) and tests covering races, corruption, and failure modes.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
cl/phase1/forkchoice/payload_vote_test.go Extends test fork-graph stub to simulate dump errors.
cl/phase1/forkchoice/on_execution_payload.go Routes persistence through persistEnvelope to downgrade “committed with durability warning” to a logged warning.
cl/phase1/forkchoice/on_execution_payload_test.go Adds regression test ensuring bookkeeping completes when persistence returns a committed-warning marker.
cl/phase1/forkchoice/fork_graph/interface.go Introduces ErrEnvelopeCommitted marker error for “committed but durability warning” semantics.
cl/phase1/forkchoice/fork_graph/fork_graph_test.go Adds extensive tests for atomicity, corruption handling, quarantine, pruning/write serialization, and ownership/race safety.
cl/phase1/forkchoice/fork_graph/fork_graph_disk.go Serializes prune with envelope operations, quarantines pruned envelopes, removes temps, and syncs directory after prune.
cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go Implements envelope temp naming, artifact cleanup, directory sync helper, quarantine helper, and hardened read/write paths.
cl/cltypes/execution_requests.go Exposes ExecutionRequests.Version() for safer version checks in persistence validation.
Suppressed comments (1)

cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go:102

  • removeOrQuarantineEnvelope returns the original Remove error even when the quarantine Rename succeeds. That makes callers treat a successful quarantine as a hard failure (e.g., Prune joins and returns an error), and the log message in ReadEnvelopeFromDisk can claim removal failed even though the file was successfully moved out of the active name. Consider returning nil on successful quarantine, and only returning an error when both Remove and Rename fail (or when Rename fails with something other than IsNotExist).
	if renameErr := fs.Rename(filename, filename+suffix); renameErr != nil && !os.IsNotExist(renameErr) {
		return errors.Join(err, fmt.Errorf("quarantine envelope: %w", renameErr))
	}
	return err

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Comment thread cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

@domiwei
domiwei marked this pull request as ready for review August 11, 2026 07:02
@domiwei
domiwei requested a review from sudeepdino008 as a code owner August 11, 2026 07:02

@awskii awskii left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the envelope-persistence hardening. Main concerns: HasEnvelope now takes the beacon-state dump lock on a path that runs under f.mu; a decode failure now destroys the file; and because cmd/caplin/caplin1/run.go:346 wipes the fork-choice directory on every start, most of the new durability machinery cannot fire in production. Details inline.

Comment thread cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go Outdated
Comment thread cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go
Comment thread cl/phase1/forkchoice/fork_graph/fork_graph_disk.go Outdated
Comment thread cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go Outdated
Comment thread cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go Outdated
Comment thread cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go Outdated
Comment thread cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go
Comment thread cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go Outdated
Comment thread cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go Outdated
Comment thread cl/phase1/forkchoice/on_execution_payload.go Outdated
@domiwei
domiwei force-pushed the kewei/gloas-envelope-storage-safety branch 3 times, most recently from d2de0bc to 9a4d23f Compare August 11, 2026 10:12
@domiwei
domiwei force-pushed the kewei/gloas-envelope-storage-safety branch from 9a4d23f to 6ad6496 Compare August 11, 2026 10:16
@domiwei
domiwei requested review from awskii and a lite review from Copilot August 12, 2026 04:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

builderIndex: builderIndex,
}
if s.seenEnvelopesCache.Contains(seenKey) {
if s.seenEnvelopesCache.Contains(seenKey) && s.forkchoiceStore.HasEnvelope(beaconBlockRoot) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gossip dedup is now bypassable. Adding && s.forkchoiceStore.HasEnvelope(beaconBlockRoot) means the seen-cache no longer short-circuits on its own.

HasEnvelope is false whenever the root sits in invalidEnvelopes (any structurally-corrupt read marks it, and the file is deliberately kept) or whenever envelopeExists was evicted. In that state every replay of the same envelope from the same builder falls through to OnExecutionPayload, which re-runs BLS verification and engine_newPayload, and re-emits execution_payload_available on SSE. A peer that can get one root into that state gets unbounded CPU + EL amplification from a single stored envelope.

If the goal is "retry when persistence failed", key that on a persistence-failure marker rather than on the general availability predicate.

f.fs.Remove(getEnvelopeFilename(root))
f.invalidEnvelopes.Delete(root)
f.stateDumpLock.Unlock()
if removeErr := f.fs.Remove(getEnvelopeFilename(root)); removeErr != nil && !errors.Is(removeErr, fs.ErrNotExist) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The envelope file is removed after stateDumpLock is released, so the cache/file invariant the PR sets out to establish still has a window.

envelopeExists.Delete(root) happens on line 621 under the lock, but Remove(getEnvelopeFilename(root)) runs after Unlock(). Between the two, a DumpEnvelopeOnDisk for that root can acquire the lock, pass knowsBlockRoot (the root is re-added to f.blocks by AddChainSegment, which stores the block early at fork_graph_disk.go:305 with no lowest-slot gate), rename its temp into place and set envelopeExists. Prune then deletes the file it just wrote.

Result: HasEnvelope(root) == true with no file on disk, and every subsequent ReadEnvelopeFromDisk fails with ErrNotExist — the sentinel ExecutionPayloadEnvelopesByRoot handler advertises the envelope and then errors.

Moving both Remove calls inside the same critical section as the map deletes closes it.

if e.BuilderExits == nil {
return fmt.Errorf("nil builder exit requests")
}
if err := e.Withdrawals.ValidateBounds(int(cfg.MaxWithdrawalRequestsPerPayload)); err != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These four lists are progressive in Gloas, so a hard len <= configured max check rejects what the decoder accepts.

For Gloas, ensureLists builds all five via NewStaticProgressiveListSSZ, whose limit is progressiveDecodeLimit(configLimit) = 2 * configLimit. progressiveDecodeLimit's own comment says: "Progressive lists are semantically unbounded, so decode limits are resource guards rather than protocol maxima." validateForPersistence follows that for Deposits (ValidateProgressiveDecodeBounds, i.e. 2x), but lines 240-251 apply the raw 1x protocol maximum to the other four.

Two consequences:

  • Inconsistent: an envelope with MaxDepositRequestsPerPayload + 1 deposits is accepted, one with MaxWithdrawalRequestsPerPayload + 1 withdrawals is rejected.
  • Read-side amplification: ReadEnvelopeFromDisk also runs this, and a failure sets invalidEnvelopes permanently. Any config tightening of these four maxima turns already-persisted envelopes into permanently unreadable ones and kills HasEnvelope for those roots.

Per cl/CLAUDE.md ("Review all changes against the upstream Ethereum consensus specifications"), either use ValidateProgressiveDecodeBounds for all five or drop the four 1x checks.


func (f *ForkChoiceStorageMock) OnExecutionPayload(ctx context.Context, signedEnvelope *cltypes.SignedExecutionPayloadEnvelope, checkBlobData, validatePayload bool) error {
if f.OnExecutionPayloadErr == nil && signedEnvelope != nil && signedEnvelope.Message != nil {
f.Envelopes[signedEnvelope.Message.BeaconBlockRoot] = signedEnvelope

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsynchronised map write, plus a nil-map panic on literal-constructed mocks.

ForkChoiceStorageMock has no mutex. executionPayloadService runs go s.loop(ctx) from its constructor; processPendingEnvelopes calls ProcessMessage -> OnExecutionPayload (this write) while the test goroutine calls ProcessMessage -> HasEnvelope, which reads f.Envelopes at line 442. That is a concurrent map read + write, which is a Go runtime fatal error, not just a -race report.

Separately, Envelopes is only allocated in NewForkChoiceStorageMock. Several tests build the mock as a literal (&mock_services.ForkChoiceStorageMock{} in cl/sentinel/handlers/blocks_by_root_test.go:91, blobs_test.go:116, rate_limiter_integration_test.go:65, ...). Any of those reaching OnExecutionPayload panics with "assignment to entry in nil map".

Guard the write with a mutex and lazily allocate the map.

envelopeLength := binary.BigEndian.Uint64(lengthBytes)
if envelopeLength > maxSSZObjectSize {
return nil, fmt.Errorf("corrupt envelope file: length %d exceeds max %d, root: %x", envelopeLength, maxSSZObjectSize, blockRoot)
if envelopeLength > clparams.MaxChunkSize {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clparams.MaxChunkSize is the package constant (mainnet 15 MiB), not the per-network f.beaconCfg.MaxChunkSize.

MAX_CHUNK_SIZE is a configurable preset — clparams.BeaconChainConfig carries it as a yaml-bound field (config.go:225) and networks can and do override it. Using the compile-time constant here (and at lines 346 and 368, and in readEnvelopeHTTPBody in both beacon_downloader.go and remote_checkpoint_sync.go) silently applies mainnet's bound to every chain.

f.beaconCfg / b.beaconCfg / r.beaconConfig are in scope at every one of those sites.

ownedTransactions[i] = bytes.Clone(transaction)
}
// TransactionsSSZ decode aliases its input, so detach it before reusing the shared buffer.
envelope.Message.Payload.Transactions = solid.NewTransactionsSSZFromTransactions(ownedTransactions)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The detach drops the decoder limits that Eth1Block.decodeSSZ installed.

Decoding builds the field as solid.NewTransactionsSSZWithLimits(cfg.MaxTransactionsPerPayload, cfg.MaxBytesPerTransaction); NewTransactionsSSZFromTransactions sets only underlying, leaving both limits zero. maxTransactions() / maxBytes() then fall back to MaxTransactionsPerPayloadDefault (1<<20) and MaxBytesPerTransactionDefault (1<<30).

The returned envelope is handed to the sentinel handlers and to chain_tip_sync, and Eth1Block.Clone() round-trips it through DecodeSSZ — that re-decode now uses the defaults instead of this chain's configured maxima.

A NewTransactionsSSZFromTransactionsWithLimits (or copying the two fields onto the new value) keeps the detach without losing the bounds.

f.blockRewards.Delete(root)
}
for _, root := range oldRoots {
f.stateDumpLock.Lock()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prune now blocks on stateDumpLock and performs file I/O under it, on the fork-choice drain path.

Prune is called from drainQueuedWork (cl/phase1/forkchoice/utils.go:69), which runs right after the fork-choice mutex is released. stateDumpLock is held for the whole of DumpBeaconStateOnDisk / readBeaconStateFromDisk — a mainnet beacon state is hundreds of MB of encode + snappy + Sync(). Previously Prune took no lock at all; now each of up to SlotsPerEpoch-many roots waits behind one of those, and f.fs.Remove(getBeaconStateFilename(root)) runs inside the critical section too.

Only the map mutations need the lock — hoist f.fs.Remove out (together with the envelope removes, see the other comment on this loop), or take the lock once for the whole map-mutation pass.

} else {
f.sszSnappyReader.Reset(file)
readTracker := &envelopeReadTracker{Reader: file}
snappyReader := snappy.NewReader(readTracker)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A fresh snappy.Reader per envelope read.

snappy.NewReader allocates ~141 KB (a 76 KB buf plus a 65 KB decoded). This path is already serialised by stateDumpLock, exactly like readBeaconStateFromDisk which reuses the pooled f.sszSnappyReader via Reset (line 70). The sentinel ExecutionPayloadEnvelopesByRange/ByRoot handlers call this once per served envelope.

Add an envelopeSnappyReader *snappy.Reader field and Reset(readTracker) it, mirroring the state path.

return fetched
}

func readEnvelopeHTTPBody(r io.Reader) ([]byte, error) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated helper. readEnvelopeHTTPBody is byte-identical to the one added at cl/phase1/core/checkpoint_sync/remote_checkpoint_sync.go:199.

Both read an envelope response under the same bound. Put one copy somewhere both packages can reach (e.g. next to the envelope types in cl/cltypes, or a small helper in cl/clparams/cl/utils) so the bound cannot drift between the two fetch paths.

filename := getEnvelopeFilename(blockRoot)
file, err = f.fs.Open(filename)
if err != nil {
if !wasCached || errors.Is(err, os.ErrNotExist) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both !wasCached eviction branches are no-ops.

wasCached is exactly envelopeExists.Load(blockRoot) taken at line 228, under the same lock, and nothing stores the key before these branches run. So when !wasCached the key is by definition absent and Delete does nothing.

Line 239 reduces to if errors.Is(err, os.ErrNotExist), and the else if err != nil && !wasCached arm in the deferred func (line 251) is dead in full. Dropping them makes the actual policy — "evict only on ErrNotExist or structural corruption" — readable.

}
}

func (e *ExtraData) ValidateBounds() error {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can never fire for SSZ-decoded extra data.

SetBytes clamps: if e.l > maxExtraDataBytes { e.l = len(e.data) }, and NewExtraData allocates data at exactly maxExtraDataBytes. So after any DecodeSSZ, e.l <= 32 always, and oversized SSZ extra data is silently truncated rather than reported here.

Only UnmarshalJSON (which replaces e.data wholesale) can produce e.l > 32, so the check only covers the REST/JSON path. If the intent is to catch oversized SSZ extra data, the guard belongs in SetBytes/DecodeSSZ returning an error, not in a post-hoc bounds check that the setter has already made unreachable.

return len(b.data)
}

func (b *ByteListSSZ) ValidateBounds(limit uint64) error {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bound is taken as a parameter while the instance already carries b.limit.

ByteListSSZ is constructed with its own limit (and DecodeSSZ/SetBytes enforce it). Taking a second, unrelated limit here means a caller that passes a different value than the one used at construction validates against the wrong maximum with no signal — and ValidateForPersistence does exactly that, passing cfg.MaxBytesPerTransaction to a list that may have been built from another config.

Either validate against b.limit and drop the parameter, or assert the two agree.

for _, root := range oldRoots {
f.stateDumpLock.Lock()
f.blocks.Delete(root)
f.fs.Remove(getBeaconStateFilename(root))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Asymmetric error handling in the same loop. The two envelope removes below now join their errors into the returned err, but the beacon-state remove on this line still discards its result.

A failing state-file remove leaks a multi-hundred-MB file per pruned root with no signal anywhere. If removal failures are worth surfacing for envelopes, they are worth surfacing for the much larger state files.

return fmt.Errorf("cannot persist envelope for root %x with embedded root %x", blockRoot, envelope.Message.BeaconBlockRoot)
}
envelopeSize := envelope.EncodingSizeSSZ()
if envelopeSize < 0 || uint64(envelopeSize) > clparams.MaxChunkSize {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant pre-check. Line 368 already rejects on len(f.sszBuffer) > MaxChunkSize using the actual encoded length, which is the authoritative value.

This earlier EncodingSizeSSZ() check adds a second, weaker bound and is not free of side effects: Eth1Block.EncodingSizeSSZ lazily assigns b.Withdrawals / b.BlockAccessList and ExecutionRequests.EncodingSizeSSZ calls ensureLists(), so the caller's envelope is mutated during what reads as a validation step. Drop it and keep the post-encode check.

go func() {
dumpDone <- f.DumpEnvelopeOnDisk(retainedRoot, testEnvelopeWithTransaction(retainedRoot, []byte{1}))
}()
time.Sleep(10 * time.Millisecond)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wall-clock synchronisation makes these prune tests flaky on loaded CI.

This time.Sleep(10ms) is meant to let the DumpEnvelopeOnDisk goroutine reach stateDumpLock; on a contended runner it may not have been scheduled yet, and the test then asserts the wrong thing. TestPruneDoesNotRaceEnvelopeReplacement has the mirror problem — its time.After(time.Second) arm makes the test always burn a full second on the expected path.

Both are expressible with the channel handshake the fixtures already provide (envelopeBlockingPruneFs.firstReached / releaseFirst): add a channel the dump goroutine closes once it is inside DumpEnvelopeOnDisk, and wait on that instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants